Skip to content

fix(cash): keep a module's authority over its own pending withdrawal after de-whitelisting - #350

Open
simonedamico wants to merge 6 commits into
masterfrom
claude/cashmodule-module-withdrawal-authority
Open

simonedamico wants to merge 6 commits into
masterfrom
claude/cashmodule-module-withdrawal-authority

Conversation

@simonedamico

@simonedamico simonedamico commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Abstract

Modules such as StockWithdrawModule (and PaymentLinkModule, #349) move a safe's funds by asking CashModule for a withdrawal with the module as recipient. Today, if governance removes such a module from CashModule's withdraw-requester list or the DataProvider whitelist while one of its withdrawals is pending, CashModule stops treating that withdrawal as the module's. This PR records which pending withdrawals a module requested, so removing a module blocks only its new requests.

How module withdrawals work

flowchart LR
  M["Module<br/>(StockWithdraw, PaymentLink)"] -->|"requestWithdrawalByModule"| CM["CashModule<br/>pending withdrawal"]
  CM -->|"after the delay:<br/>process"| M
  X["Card spend, preLiquidate,<br/>owner's own withdrawal"] -->|"cancelOldWithdrawal"| CM
  CM -->|"cancelBridgeByCashModule<br/>callback"| M
Loading

A safe has at most one pending withdrawal. A module requests it, CashModule holds it for the withdrawal delay, and then it is processed into the module. A card spend, a preLiquidate, or the owners' own withdrawal cancels it and calls the module back so the module can update its records.

What goes wrong today after a module is removed while its withdrawal is pending:

  • Anyone can processWithdrawal the hold into the module, skipping the module's bookkeeping. For PaymentLinkModule, the sender's funds arrive as unrecorded surplus.
  • The module can no longer cancel its own hold.
  • cancelOldWithdrawal skips the callback, so the module's record goes stale.

Until this PR is deployed, #349 relies on a governance rule: never de-whitelist PaymentLinkModule while a link is Requested.

What changes

  • Record: requestWithdrawalByModule sets SafeCashConfig.withdrawalRequestedByModule. Every path that deletes the pending request, whether processing or cancelling, clears it.
  • Authority: CashLendLib.isModuleWithdrawal(safe) holds when the record is set or the recipient is still on the requester list. Processing, the owners' cancelWithdrawal and cancelWithdrawalByModule all use it, so:
    • only the recipient module can process its recorded withdrawal;
    • the module can still cancel it;
    • owners still can't cancel a module's withdrawal directly. They cancel through the module, replace it with their own withdrawal, or a spend cancels it. For StockWithdrawModule after a DataProvider removal, the owner-signed cancel can't get a nonce, so owners use cancelExpiredWithdrawal or a replacement withdrawal (tested).
  • Cancel callback:
    • A module still on both lists gets the callback as before, and a revert still reverts the cancel.
    • A removed module with a recorded request gets _notifyRemovedModule: a call capped at 200k gas whose result is ignored. A broken or retired module therefore can't block spends, preLiquidate or owner replacements.
  • Older requests: requests made before the upgrade carry no record and keep today's rules until they clear.

Merge order with #349

#349's tests pin today's CashModule behavior, so they fail against this change. claude/plm-tests-on-cashmodule-fix is #349 with this branch merged in and its tests updated (101 passed). Whichever of the two PRs merges second carries those test changes.

Storage layout

One bool is appended at the end of SafeCashConfig, a mapping value, and packs into its existing last slot. The struct size is unchanged, and WithdrawalRequest and SafeData (the getData ABI) are untouched. CashModuleStorage itself is unchanged at 544 bytes, including after the rebase onto master's moduleWithdrawalDelayConfig. test_record_packsIntoSafeCashConfigTailSlot pins the slot and offset.

 t_struct(SafeCashConfig) numberOfBytes=576
   slot 17 off  0 incomingMode t_enum(Mode)
   slot 17 off  1 lendOptedOut t_bool
   slot 17 off  2 lendOptOutFinalizeTime t_uint96
   slot 17 off 14 usesLendGateway t_bool
+  slot 17 off 15 withdrawalRequestedByModule t_bool

Upgrade path (not in this PR)

  • Deploy: a new CashLendLib (a linked library), then new CashModuleCore and CashModuleSetters implementations through CREATE3 with fresh salts, both linked to that library.
  • Upgrade: one 2-day upgrade-timelock batch with upgradeToAndCall(core, "") and setCashModuleSettersAddress(setters), then a verify script. Both must land together: a mixed old and new pair could create requests without the record or leave a stale one.
  • Before removing a module: wait until its pre-upgrade holds have cleared, as the existing preflight requires.
  • Sizes against master: CashModuleCore is 24,058 B (518 B margin), and CashModuleSetters is 23,742 B (834 B margin).
  • Skipped checks: test_verifyBytecode_CashModuleCore and _CashModuleSetters in VerifyOPMainnet.t.sol are skipped until the upgrade is live.

CI

Every check passes except Run Tests (deployed), which fails only on test_verifyBytecode_TopUpDest, exactly as on master. On 2026-10-22 the TopUpDest proxy on Optimism was upgraded to code from #331, which hasn't merged, so master's TopUpDest.sol no longer matches. The check passes once #331 lands.

Testing

New test/safe/modules/cash/ModuleWithdrawalAuthority.t.sol (mock module, 30 tests) covers:

  • removal from the requester list only, from the DataProvider only, and both orders, for processing and for module cancel;
  • the owners' direct cancel is still rejected;
  • owner replacements and card spends still notify a removed module, and its reverting callbacks can't block them or preLiquidate;
  • a gas-burning callback is capped, and the outer gas limit can't starve the callback;
  • the record's lifecycle and slot;
  • requests without the record follow today's rules.

StockWithdrawModule.t.sol covers both removal orders against the real module, with a 1k-step sweep of the outer gas limit. Withdrawals.t.sol now asserts that the module can still cancel after a DataProvider removal.

Run locally on an Optimism fork:

  • test/safe/modules/cash/**: 457 passed, before the last commit added one test; ModuleWithdrawalAuthority.t.sol re-run afterward: 30 passed;
  • StockWithdrawModule.t.sol: 59 passed;
  • the lend profile suites: 133 passed;
  • VerifyOPMainnet.t.sol: 38 passed, 5 skipped, and only the TopUpDest check failed, as on master.

Follow-ups (not in this PR)

  • A StockWithdrawModule reconcile, so a missed callback can't strand an order.
  • Deploy and verify scripts for the CashModule upgrade.
  • After the upgrade is live: re-enable the two VerifyOPMainnet checks, and scope [PAY-1170] Add PaymentLinkModule and deploy scripts #349's governance rule to pre-upgrade holds.

🤖 Generated with Claude Code


Note

Medium Risk
Changes CashModule withdrawal authority and cancel callbacks affecting bridge modules (StockWithdraw, PaymentLink), but behavior is narrowed and guarded with gas-capped notifications; requires a coordinated impl upgrade so requests always carry the new record.

Overview
Fixes a governance edge case where de-whitelisting a withdraw-requesting module while its hold was pending let third parties process the withdrawal into the module (skipping bridge bookkeeping) and blocked the module from canceling it; cancelOldWithdrawal also stopped calling cancelBridgeByCashModule after DataProvider removal.

requestWithdrawalByModule now sets a per-request withdrawalRequestedByModule flag (packed in SafeCashConfig). CashLendLib.isModuleWithdrawal treats a hold as module-owned when that flag is set or the recipient is still on the withdraw-requester list (legacy behavior for older requests). Process, owner cancel, and cancelWithdrawalByModule use this helper so removal only blocks new requests—the recipient module keeps exclusive process/cancel rights, including after DataProvider delist when the flag is set.

When a competing cancel clears a recorded module hold and the module is no longer on both whitelists, cancelOldWithdrawal invokes _notifyRemovedModule: a 200k-gas-capped, best-effort cancelBridgeByCashModule call whose success/revert cannot block the cancel (spends, liquidation prep, owner replacement). Whitelisted modules still get the strict callback that can revert the cancel.

Extensive tests cover mock and StockWithdrawModule flows; OP bytecode verify for CashModule impls is temporarily skipped pending deploy.

Reviewed by Cursor Bugbot for commit 51baee7. Bugbot is set up for automated code reviews on this repo. Configure here.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@certora-run certora-run Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification Results

  • Group ID: a49ffa13-1154-48c4-a1a7-773e978cb63f
  • Commit: 551fc55
JobResultVERIFIED
EtherFiSafe.…✅14
CashModuleCo…✅2

@simonedamico
simonedamico force-pushed the claude/cashmodule-module-withdrawal-authority branch from 551fc55 to f99154a Compare October 26, 2026 11:25

@certora-run certora-run Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification Results

  • Group ID: adfa26bb-770a-40b5-a0e8-6c6c789b5c91
  • Commit: f99154a
JobResultVERIFIED
EtherFiSafe.…✅14
CashModuleCo…✅2

simonedamico and others added 6 commits October 29, 2026 18:55
…after de-whitelisting

CashModule decided who may process or cancel a module's pending withdrawal from current
whitelist membership. Once governance removed the module from the withdraw-requester list or
the DataProvider whitelist, anyone could process the hold into the module (bypassing the
module's bookkeeping, which strands funds in an escrow like PaymentLinkModule), the module
could no longer cancel it, and its cancelBridgeByCashModule callback was skipped.

requestWithdrawalByModule now records the request as the module's own
(SafeCashConfig.withdrawalRequestedByModule, appended at slot 17 offset 15, packed after
usesLendGateway). Processing, owner cancel and module cancel read that record, so removal only
blocks new requests. Requests without the record keep the current-membership rules.

On cancel, a module still on both whitelists is called as before. A removed module whose
request carries the record is still notified, through a call capped at 200k gas whose result
and return data are ignored, so a broken or retired module cannot block spends, liquidations
or the owners' replacement request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…telist

CashModule-level tests with a mock module (both removal orders and single-list removals,
reverting and gas-burning callbacks, the record's slot and lifecycle, requests without the
record) and StockWithdrawModule tests for both removal orders. The existing test that pinned
the old DataProvider-removal revert now asserts the module can still cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The local CashModuleCore and CashModuleSetters now carry the per-request module withdrawal
record, so they no longer match the deployed implementations (both checks pass on master).
Same pattern as the EtherFiSafeFactory skip: re-enable once the CashModule upgrade is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut gas starvation

From the Codex review: the StockWithdrawModule callback gas check now places the order in
setUp, so the callback deletes persisted storage in a later transaction (68,160 gas against
the 200k cap). A new test binary-searches the outer gas limit of a relayed owner replacement
and requires every successful probe to have delivered the capped callback. The natspec now
says plainly that a removed module's callback still runs; it can only no longer block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le callback

From the second Codex pass: a binary search alone assumes success is monotonic in gas. Both
starvation tests now also sweep every outer gas limit from far below the cheapest successful
call to 100k above it in 1k steps, and require each success to have delivered the callback.
The StockWithdrawModule version runs against the real module with the order and its removal
persisted in setUp. A 40k cap mutation fails both StockWithdrawModule callback tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay override

After rebasing onto master's per-module withdrawal delays, a module whose override is zero
(global delay nonzero) processes its request inline. The record is written before that
processing and cleared by it. Suggested by the Codex review of the rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonedamico
simonedamico force-pushed the claude/cashmodule-module-withdrawal-authority branch from f99154a to 51baee7 Compare October 29, 2026 18:56

@certora-run certora-run Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification Results

  • Group ID: be8cae8d-277c-48b3-9ae4-973c3218a151
  • Commit: 51baee7
JobResultVERIFIED
EtherFiSafe.…✅14
CashModuleCo…✅2

@simonedamico
simonedamico marked this pull request as ready for review October 31, 2026 06:29
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-31T06:29:18.598979Z
We couldn’t complete this review. Comment @codex review to try again.
51baee7 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

simonedamico added a commit that referenced this pull request Nov 1, 2026
Whether production enablement waits for cash-v3#350 is an open release-checklist
decision; the 3CP header now says the governance rule applies unless #350 is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simonedamico added a commit that referenced this pull request Nov 4, 2026
Whether production enablement waits for cash-v3#350 is an open release-checklist
decision; the 3CP header now says the governance rule applies unless #350 is live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant